<?php

/**
 * OAuth2.0 draft v10 client-side implementation.
 *
 * @author Originally written by Naitik Shah <naitik@facebook.com>.
 * @author Update to draft v10 by Edison Wong <hswong3i@pantarei-design.com>.
 *
 * @sa <a href="https://github.com/facebook/php-sdk">Facebook PHP SDK</a>.
 */
abstract class OAuth2Client {
	
	/**
	 * The default Cache Lifetime (in seconds).
	 */
	const DEFAULT_EXPIRES_IN = 3600;
	
	/**
	 * The default Base domain for the Cookie.
	 */
	const DEFAULT_BASE_DOMAIN = '';
	
	/**
	 * Array of persistent variables stored.
	 */
	protected $conf = array();

	/**
	 * Returns a persistent variable.
	 *
	 * To avoid problems, always use lower case for persistent variable names.
	 *
	 * @param $name
	 * The name of the variable to return.
	 * @param $default
	 * The default value to use if this variable has never been set.
	 *
	 * @return
	 * The value of the variable.
	 */
	public function getVariable($name, $default = NULL) {
		return isset($this->conf[$name]) ? $this->conf[$name] : $default;
	}

	/**
	 * Sets a persistent variable.
	 *
	 * To avoid problems, always use lower case for persistent variable names.
	 *
	 * @param $name
	 * The name of the variable to set.
	 * @param $value
	 * The value to set.
	 */
	public function setVariable($name, $value) {
		$this->conf[$name] = $value;
		return $this;
	}

	// Stuff that should get overridden by subclasses.
	//
	// I don't want to make these abstract, because then subclasses would have
	// to implement all of them, which is too much work.
	//
	// So they're just stubs. Override the ones you need.
	

	/**
	 * Initialize a Drupal OAuth2.0 Application.
	 *
	 * @param $config
	 * An associative array as below:
	 * - base_uri: The base URI for the OAuth2.0 endpoints.
	 * - code: (optional) The authorization code.
	 * - username: (optional) The username.
	 * - password: (optional) The password.
	 * - client_id: (optional) The application ID.
	 * - client_secret: (optional) The application secret.
	 * - authorize_uri: (optional) The end-user authorization endpoint URI.
	 * - access_token_uri: (optional) The token endpoint URI.
	 * - services_uri: (optional) The services endpoint URI.
	 * - cookie_support: (optional) TRUE to enable cookie support.
	 * - base_domain: (optional) The domain for the cookie.
	 * - file_upload_support: (optional) TRUE if file uploads are enabled.
	 */
	public function __construct($config = array()) {
		// We must set base_uri first.
		$this->setVariable('base_uri', $config['base_uri']);
		unset($config['base_uri']);
		
		// Use predefined OAuth2.0 params, or get it from $_REQUEST.
		foreach ( array('code', 'username', 'password') as $name ) {
			if (isset($config[$name])) {
				$this->setVariable($name, $config[$name]);
			} else if (isset($_REQUEST[$name]) && !empty($_REQUEST[$name])) {
				$this->setVariable($name, $_REQUEST[$name]);
			}
			unset($config[$name]);
		}
		
		// Endpoint URIs.
		foreach ( array('authorize_uri', 'access_token_uri', 'services_uri') as $name ) {
			if (isset($config[$name]))
				if (substr($config[$name], 0, 4) == "http") {
					$this->setVariable($name, $config[$name]);
				} else {
					$this->setVariable($name, $this->getVariable('base_uri') . $config[$name]);
				}
			unset($config[$name]);
		}
		
		// Other else configurations.
		foreach ( $config as $name => $value ) {
			$this->setVariable($name, $value);
		}
	}

	/**
	 * Try to get session object from custom method.
	 *
	 * By default we generate session object based on access_token response, or
	 * if it is provided from server with $_REQUEST. For sure, if it is provided
	 * by server it should follow our session object format.
	 *
	 * Session object provided by server can ensure the correct expires and
	 * base_domain setup as predefined in server, also you may get more useful
	 * information for custom functionality, too. BTW, this may require for
	 * additional remote call overhead.
	 *
	 * You may wish to override this function with your custom version due to
	 * your own server-side implementation.
	 *
	 * @param $access_token
	 * (optional) A valid access token in associative array as below:
	 * - access_token: A valid access_token generated by OAuth2.0
	 * authorization endpoint.
	 * - expires_in: (optional) A valid expires_in generated by OAuth2.0
	 * authorization endpoint.
	 * - refresh_token: (optional) A valid refresh_token generated by OAuth2.0
	 * authorization endpoint.
	 * - scope: (optional) A valid scope generated by OAuth2.0
	 * authorization endpoint.
	 *
	 * @return
	 * A valid session object in associative array for setup cookie, and
	 * NULL if not able to generate it with custom method.
	 */
	protected function getSessionObject($access_token = NULL) {
		$session = NULL;
		
		// Try generate local version of session cookie.
		if (!empty($access_token) && isset($access_token['access_token'])) {
			$session['access_token'] = $access_token['access_token'];
			$session['base_domain'] = $this->getVariable('base_domain', self::DEFAULT_BASE_DOMAIN);
			$session['expires'] = isset($access_token['expires_in']) ? time() + $access_token['expires_in'] : time() + $this->getVariable('expires_in', self::DEFAULT_EXPIRES_IN);
			$session['refresh_token'] = isset($access_token['refresh_token']) ? $access_token['refresh_token'] : '';
			$session['scope'] = isset($access_token['scope']) ? $access_token['scope'] : '';
			$session['secret'] = md5(base64_encode(pack('N6', mt_rand(), mt_rand(), mt_rand(), mt_rand(), mt_rand(), uniqid())));
			
			// Provide our own signature.
			$sig = self::generateSignature($session, $this->getVariable('client_secret'));
			$session['sig'] = $sig;
		}
		
		// Try loading session from $_REQUEST.
		if (!$session && isset($_REQUEST['session'])) {
			$session = json_decode(get_magic_quotes_gpc() ? stripslashes($_REQUEST['session']) : $_REQUEST['session'], TRUE);
		}
		
		return $session;
	}

	/**
	 * Make an API call.
	 *
	 * Support both OAuth2.0 or normal GET/POST API call, with relative
	 * or absolute URI.
	 *
	 * If no valid OAuth2.0 access token found in session object, this function
	 * will automatically switch as normal remote API call without "oauth_token"
	 * parameter.
	 *
	 * Assume server reply in JSON object and always decode during return. If
	 * you hope to issue a raw query, please use makeRequest().
	 *
	 * @param $path
	 * The target path, relative to base_path/service_uri or an absolute URI.
	 * @param $method
	 * (optional) The HTTP method (default 'GET').
	 * @param $params
	 * (optional The GET/POST parameters.
	 *
	 * @return
	 * The JSON decoded response object.
	 *
	 * @throws OAuth2Exception
	 */
	public function api($path, $method = 'GET', $params = array()) {
		if (is_array($method) && empty($params)) {
			$params = $method;
			$method = 'GET';
		}
		
		// json_encode all params values that are not strings.
		foreach ( $params as $key => $value ) {
			if (!is_string($value)) {
				$params[$key] = json_encode($value);
			}
		}
		
		$result = json_decode($this->makeOAuth2Request($this->getUri($path), $method, $params), TRUE);
		
		// Results are returned, errors are thrown.
		if (is_array($result) && isset($result['error'])) {
			$e = new OAuth2Exception($result);
			switch ($e->getType()) {
				// OAuth 2.0 Draft 10 style.
				case 'invalid_token':
					$this->setSession(NULL);
				default :
					$this->setSession(NULL);
			}
			throw $e;
		}
		return $result;
	}
	
	// End stuff that should get overridden.
	

	/**
	 * Default options for cURL.
	 */
	public static $CURL_OPTS = array(
		CURLOPT_CONNECTTIMEOUT => 10,
		CURLOPT_RETURNTRANSFER => TRUE,
		CURLOPT_HEADER => TRUE,
		CURLOPT_TIMEOUT => 60,
		CURLOPT_USERAGENT => 'oauth2-draft-v10',
		CURLOPT_HTTPHEADER => array("Accept: application/json")
	);

	/**
	 * Set the Session.
	 *
	 * @param $session
	 * (optional) The session object to be set. NULL if hope to frush existing
	 * session object.
	 * @param $write_cookie
	 * (optional) TRUE if a cookie should be written. This value is ignored
	 * if cookie support has been disabled.
	 *
	 * @return
	 * The current OAuth2.0 client-side instance.
	 */
	public function setSession($session = NULL, $write_cookie = TRUE) {
		$this->setVariable('_session', $this->validateSessionObject($session));
		$this->setVariable('_session_loaded', TRUE);
		if ($write_cookie) {
			$this->setCookieFromSession($this->getVariable('_session'));
		}
		return $this;
	}

	/**
	 * Get the session object.
	 *
	 * This will automatically look for a signed session via custom method,
	 * OAuth2.0 grant type with authorization_code, OAuth2.0 grant type with
	 * password, or cookie that we had already setup.
	 *
	 * @return
	 * The valid session object with OAuth2.0 infomration, and NULL if not
	 * able to discover any cases.
	 */
	public function getSession() {
		if (!$this->getVariable('_session_loaded')) {
			$session = NULL;
			$write_cookie = TRUE;
			
			// Try obtain login session by custom method.
			$session = $this->getSessionObject(NULL);
			$session = $this->validateSessionObject($session);
			
			// grant_type == authorization_code.
			if (!$session && $this->getVariable('code')) {
				$access_token = $this->getAccessTokenFromAuthorizationCode($this->getVariable('code'));
				$session = $this->getSessionObject($access_token);
				$session = $this->validateSessionObject($session);
			}
			
			// grant_type == password.
			if (!$session && $this->getVariable('username') && $this->getVariable('password')) {
				$access_token = $this->getAccessTokenFromPassword($this->getVariable('username'), $this->getVariable('password'));
				$session = $this->getSessionObject($access_token);
				$session = $this->validateSessionObject($session);
			}
			
			// Try loading session from cookie if necessary.
			if (!$session && $this->getVariable('cookie_support')) {
				$cookie_name = $this->getSessionCookieName();
				if (isset($_COOKIE[$cookie_name])) {
					$session = array();
					parse_str(trim(get_magic_quotes_gpc() ? stripslashes($_COOKIE[$cookie_name]) : $_COOKIE[$cookie_name], '"'), $session);
					$session = $this->validateSessionObject($session);
					// Write only if we need to delete a invalid session cookie.
					$write_cookie = empty($session);
				}
			}
			
			$this->setSession($session, $write_cookie);
		}
		
		return $this->getVariable('_session');
	}

	/**
	 * Gets an OAuth2.0 access token from session.
	 *
	 * This will trigger getSession() and so we MUST initialize with required
	 * configuration.
	 *
	 * @return
	 * The valid OAuth2.0 access token, and NULL if not exists in session.
	 */
	public function getAccessToken() {
		$session = $this->getSession();
		return isset($session['access_token']) ? $session['access_token'] : NULL;
	}

	/**
	 * Get access token from OAuth2.0 token endpoint with authorization code.
	 *
	 * This function will only be activated if both access token URI, client
	 * identifier and client secret are setup correctly.
	 *
	 * @param $code
	 * Authorization code issued by authorization server's authorization
	 * endpoint.
	 *
	 * @return
	 * A valid OAuth2.0 JSON decoded access token in associative array, and
	 * NULL if not enough parameters or JSON decode failed.
	 */
	private function getAccessTokenFromAuthorizationCode($code) {
		if ($this->getVariable('access_token_uri') && $this->getVariable('client_id') && $this->getVariable('client_secret')) {
			return json_decode($this->makeRequest(
				$this->getVariable('access_token_uri'),
				'POST',
				array(
					'grant_type' => 'authorization_code',
					'client_id' => $this->getVariable('client_id'),
					'client_secret' => $this->getVariable('client_secret'),
					'code' => $code,
					'redirect_uri' => $this->getCurrentUri()
				)
			), TRUE);
		}
		return NULL;
	}

	/**
	 * Get access token from OAuth2.0 token endpoint with basic user
	 * credentials.
	 *
	 * This function will only be activated if both username and password
	 * are setup correctly.
	 *
	 * @param $username
	 * Username to be check with.
	 * @param $password
	 * Password to be check with.
	 *
	 * @return
	 * A valid OAuth2.0 JSON decoded access token in associative array, and
	 * NULL if not enough parameters or JSON decode failed.
	 */
	private function getAccessTokenFromPassword($username, $password) {
		if ($this->getVariable('access_token_uri') && $this->getVariable('client_id') && $this->getVariable('client_secret')) {
			return json_decode($this->makeRequest(
				$this->getVariable('access_token_uri'),
				'POST',
				array(
					'grant_type' => 'password',
					'client_id' => $this->getVariable('client_id'),
					'client_secret' => $this->getVariable('client_secret'),
					'username' => $username,
					'password' => $password
				)
			), TRUE);
		}
		return NULL;
	}

	/**
	 * Make an OAuth2.0 Request.
	 *
	 * Automatically append "oauth_token" in query parameters if not yet
	 * exists and able to discover a valid access token from session. Otherwise
	 * just ignore setup with "oauth_token" and handle the API call AS-IS, and
	 * so may issue a plain API call without OAuth2.0 protection.
	 *
	 * @param $path
	 * The target path, relative to base_path/service_uri or an absolute URI.
	 * @param $method
	 * (optional) The HTTP method (default 'GET').
	 * @param $params
	 * (optional The GET/POST parameters.
	 *
	 * @return
	 * The JSON decoded response object.
	 *
	 * @throws OAuth2Exception
	 */
	protected function makeOAuth2Request($path, $method = 'GET', $params = array()) {
		if ((!isset($params['oauth_token']) || empty($params['oauth_token'])) && $oauth_token = $this->getAccessToken()) {
			$params['oauth_token'] = $oauth_token;
		}
		return $this->makeRequest($path, $method, $params);
	}

	/**
	 * Makes an HTTP request.
	 *
	 * This method can be overriden by subclasses if developers want to do
	 * fancier things or use something other than cURL to make the request.
	 *
	 * @param $path
	 * The target path, relative to base_path/service_uri or an absolute URI.
	 * @param $method
	 * (optional) The HTTP method (default 'GET').
	 * @param $params
	 * (optional The GET/POST parameters.
	 * @param $ch
	 * (optional) An initialized curl handle
	 *
	 * @return
	 * The JSON decoded response object.
	 */
	protected function makeRequest($path, $method = 'GET', $params = array(), $ch = NULL) {
		if (!$ch)
			$ch = curl_init();
		
		$opts = self::$CURL_OPTS;
		if ($params) {
			switch ($method) {
				case 'GET':
					$path .= '?' . http_build_query($params, NULL, '&');
					break;
				// Method override as we always do a POST.
				default :
					if ($this->getVariable('file_upload_support')) {
						$opts[CURLOPT_POSTFIELDS] = $params;
					} else {
						$opts[CURLOPT_POSTFIELDS] = http_build_query($params, NULL, '&');
					}
			}
		}
		$opts[CURLOPT_URL] = $path;
		
		// Disable the 'Expect: 100-continue' behaviour. This causes CURL to wait
		// for 2 seconds if the server does not support this header.
		if (isset($opts[CURLOPT_HTTPHEADER])) {
			$existing_headers = $opts[CURLOPT_HTTPHEADER];
			$existing_headers[] = 'Expect:';
			$opts[CURLOPT_HTTPHEADER] = $existing_headers;
		} else {
			$opts[CURLOPT_HTTPHEADER] = array('Expect:');
		}
		
		curl_setopt_array($ch, $opts);
		$result = curl_exec($ch);
		
		if (curl_errno($ch) == 60) { // CURLE_SSL_CACERT
			error_log('Invalid or no certificate authority found, using bundled information');
			curl_setopt($ch, CURLOPT_CAINFO, dirname(__FILE__) . '/fb_ca_chain_bundle.crt');
			$result = curl_exec($ch);
		}
		
		if ($result === FALSE) {
			$e = new OAuth2Exception(array('code' => curl_errno($ch), 'message' => curl_error($ch)));
			curl_close($ch);
			throw $e;
		}
		curl_close($ch);
		
		// Split the HTTP response into header and body.
		list($headers, $body) = explode("\r\n\r\n", $result);
		$headers = explode("\r\n", $headers);
		
		// We catch HTTP/1.1 4xx or HTTP/1.1 5xx error response.
		if (strpos($headers[0], 'HTTP/1.1 4') !== FALSE || strpos($headers[0], 'HTTP/1.1 5') !== FALSE) {
			$result = array('code' => 0, 'message' => '');
			
			if (preg_match('/^HTTP\/1.1 ([0-9]{3,3}) (.*)$/', $headers[0], $matches)) {
				$result['code'] = $matches[1];
				$result['message'] = $matches[2];
			}
			
			// In case retrun with WWW-Authenticate replace the description.
			foreach ( $headers as $header ) {
				if (preg_match("/^WWW-Authenticate:.*error='(.*)'/", $header, $matches)) {
					$result['error'] = $matches[1];
				}
			}
			
			return json_encode($result);
		}
		
		return $body;
	}

	/**
	 * The name of the cookie that contains the session object.
	 *
	 * @return
	 * The cookie name.
	 */
	private function getSessionCookieName() {
		return 'oauth2_' . $this->getVariable('client_id');
	}

	/**
	 * Set a JS Cookie based on the _passed in_ session.
	 *
	 * It does not use the currently stored session - you need to explicitly
	 * pass it in.
	 *
	 * @param $session
	 * The session to use for setting the cookie.
	 */
	protected function setCookieFromSession($session = NULL) {
		if (!$this->getVariable('cookie_support'))
			return;
		
		$cookie_name = $this->getSessionCookieName();
		$value = 'deleted';
		$expires = time() - 3600;
		$base_domain = $this->getVariable('base_domain', self::DEFAULT_BASE_DOMAIN);
		if ($session) {
			$value = '"' . http_build_query($session, NULL, '&') . '"';
			$base_domain = isset($session['base_domain']) ? $session['base_domain'] : $base_domain;
			$expires = isset($session['expires']) ? $session['expires'] : time() + $this->getVariable('expires_in', self::DEFAULT_EXPIRES_IN);
		}
		
		// Prepend dot if a domain is found.
		if ($base_domain)
			$base_domain = '.' . $base_domain;
		
		// If an existing cookie is not set, we dont need to delete it.
		if ($value == 'deleted' && empty($_COOKIE[$cookie_name]))
			return;
		
		if (headers_sent())
			error_log('Could not set cookie. Headers already sent.');
		else
			setcookie($cookie_name, $value, $expires, '/', $base_domain);
	}

	/**
	 * Validates a session_version = 3 style session object.
	 *
	 * @param $session
	 * The session object.
	 *
	 * @return
	 * The session object if it validates, NULL otherwise.
	 */
	protected function validateSessionObject($session) {
		// Make sure some essential fields exist.
		if (is_array($session) && isset($session['access_token']) && isset($session['sig'])) {
			// Validate the signature.
			$session_without_sig = $session;
			unset($session_without_sig['sig']);
			
			$expected_sig = self::generateSignature($session_without_sig, $this->getVariable('client_secret'));
			
			if ($session['sig'] != $expected_sig) {
				error_log('Got invalid session signature in cookie.');
				$session = NULL;
			}
		} else {
			$session = NULL;
		}
		return $session;
	}

	/**
	 * Since $_SERVER['REQUEST_URI'] is only available on Apache, we
	 * generate an equivalent using other environment variables.
	 */
	function getRequestUri() {
		if (isset($_SERVER['REQUEST_URI'])) {
			$uri = $_SERVER['REQUEST_URI'];
		} else {
			if (isset($_SERVER['argv'])) {
				$uri = $_SERVER['SCRIPT_NAME'] . '?' . $_SERVER['argv'][0];
			} elseif (isset($_SERVER['QUERY_STRING'])) {
				$uri = $_SERVER['SCRIPT_NAME'] . '?' . $_SERVER['QUERY_STRING'];
			} else {
				$uri = $_SERVER['SCRIPT_NAME'];
			}
		}
		// Prevent multiple slashes to avoid cross site requests via the Form API.
		$uri = '/' . ltrim($uri, '/');
		
		return $uri;
	}

	/**
	 * Returns the Current URL.
	 *
	 * @return
	 * The current URL.
	 */
	protected function getCurrentUri() {
		$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on' ? 'https://' : 'http://';
		$current_uri = $protocol . $_SERVER['HTTP_HOST'] . $this->getRequestUri();
		$parts = parse_url($current_uri);
		
		$query = '';
		if (!empty($parts['query'])) {
			$params = array();
			parse_str($parts['query'], $params);
			$params = array_filter($params);
			if (!empty($params)) {
				$query = '?' . http_build_query($params, NULL, '&');
			}
		}
		
		// Use port if non default.
		$port = '';
		if (isset($parts['port']) && (($protocol === 'http://' && $parts['port'] !== 80) || ($protocol === 'https://' && $parts['port'] !== 443))) {
			$port = ':' . $parts['port'];
		}
		
		
		// Rebuild.
		return $protocol . $parts['host'] . $port . $parts['path'] . $query;
	}

	/**
	 * Build the URL for given path and parameters.
	 *
	 * @param $path
	 * (optional) The path.
	 * @param $params
	 * (optional) The query parameters in associative array.
	 *
	 * @return
	 * The URL for the given parameters.
	 */
	protected function getUri($path = '', $params = array()) {
		$url = $this->getVariable('services_uri') ? $this->getVariable('services_uri') : $this->getVariable('base_uri');
		
		if (!empty($path))
			if (substr($path, 0, 4) == "http")
				$url = $path;
			else
				$url = rtrim($url, '/') . '/' . ltrim($path, '/');
		
		if (!empty($params))
			$url .= '?' . http_build_query($params, NULL, '&');
		
		return $url;
	}

	/**
	 * Generate a signature for the given params and secret.
	 *
	 * @param $params
	 * The parameters to sign.
	 * @param $secret
	 * The secret to sign with.
	 *
	 * @return
	 * The generated signature
	 */
	protected function generateSignature($params, $secret) {
		// Work with sorted data.
		ksort($params);
		
		// Generate the base string.
		$base_string = '';
		foreach ( $params as $key => $value ) {
			$base_string .= $key . '=' . $value;
		}
		$base_string .= $secret;
		
		return md5($base_string);
	}
}
